Lws49/feat marketplace pr8 singleton attempt - #8521
Draft
LWS49 wants to merge 43 commits into
Draft
Conversation
- add Listing and Adoption models under Course::Assessment::Marketplace namespace, with course_assessment_marketplace_ prefix on both tables - Listing tracks published state and publisher, with a uniqueness constraint per assessment and an adoption_count helper - Adoption links a listing to a destination course and duplicated assessment, one adoption per duplicated assessment - wire has_one :marketplace_listing onto Course::Assessment - add AssessmentMarketplaceAbilityComponent: admins can publish listings, course managers/owners can access, duplicate, and preview published listings
- add publish/remove listing endpoints (admin-gated create/destroy) - expose canPublishToMarketplace + listing state on assessment show DTO - add Publish/Remove to Marketplace button on the assessment header - warn in the delete Prompt when a listed assessment is removed - add MarketplaceAPI client, translations, and controller/FE specs
- add cross-instance listings index (published only, live counts) - add browse page with title search, adoptions/newest sort, row select - add sidebar admin entry + /courses/:id/marketplace route - add "Import Assessments" button on assessments index (from_tab) - add FE api/operations/types, controller + component specs
- add DuplicationJob: copies listings into a course tab, writes adoption - add bulk duplicate endpoint enqueuing the job for selected listings - add DuplicateConfirmation modal with row + bulk triggers, job polling - add MarketplaceAPI.duplicate and duplicateListings poll operation - serialize and assert live distinct-course adoption count in index
Add the read-only backend for the marketplace browse flow: - listings#show serializes a curated, read-only view of a published assessment (config + per-question summaries) for the listing preview. - questions#show serializes a single question's detail, dispatching to per-type detail partials (multiple/text/voice/forum/programming/ rubric/scribing) so each renderer gets exactly the data it needs. - The listings index gains destination tabs plus preview/duplicate URLs so the browse table can link into the flow and target a tab. Type labels are serialized human-readable (question_type_readable) to match the real assessment show page, while the demodulized discriminator is kept for frontend renderer dispatch. The base controller pulls in AssessmentsHelper so the preview views can reuse display_graded_test_types, and the sidebar component now uses the :marketplace (storefront) icon.
…port Extract the assessment/tab/question tree from AssessmentsListing into a reusable DuplicationAssessmentTree component so both the duplication page and the marketplace duplicate dialog render an identical tree. The old DuplicateItemsConfirmation listing is rewired onto it. Also add the shared table primitives the marketplace index needs: - renderEmpty flows through TableTemplate -> Body -> MuiTable so a table can render a custom empty state when it has no rows. - hideSelectAll drops the select-all header checkbox while keeping the per-row checkboxes. - Register the storefront icon in COURSE_COMPONENT_ICONS.
Build the read-only browse experience on top of the preview endpoints: - Marketplace index: single-toolbar table with pagination, empty states, hidden select-all, and links into the listing preview / duplicate flow. - Listing preview page: read-only assessment config, per-question cards (type chip, staff-only notes, expandable options) and a Duplicate Assessment action. - Question detail preview: header chip plus a renderer dispatcher with a renderer per question type (multiple/text/voice/forum/programming/ rubric/scribing). - Duplicate dialog now shows the destination course and the shared assessment tree. Includes the api client, operations, types, translations and locale strings backing the above.
Thread the origin assessment tab (from_tab) through the whole browse flow (index -> listing -> question preview and back) via withFromTab helpers, so a duplication always imports into the tab the user started from no matter how they navigate. Add the route data handles that build the marketplace / listing / question breadcrumbs, preserving from_tab on the crumb links.
Replace the static destination summary with an in-dialog tab picker and report duplication results honestly: - Add DestinationTabPicker, a radio tree grouping the current course's tabs by category, so the duplicator chooses the destination tab inside the dialog instead of it being fixed by the launching `from_tab`. The selection seeds from `from_tab` (falling back to the first tab) and re-seeds on each reopen, but a parent re-render never resets a choice mid-decision. - Serve `destinationTabs` from the listing show endpoint too, so the picker is available when duplicating from the listing detail page, not just the marketplace index. - Restyle the dialog: vertically stacked tabs with larger category/tab text, a dense TypeBadge variant, an explicit "Duplicating" heading, the ⊘ "arrives unpublished" hint, and explicit cancel/primary colors. - Report a *completed* duplication (the toast fires from pollJob's completion callback, not on submit) and link to where the copy landed via the job's redirectUrl; reword the failure copy to plain language. Widen the shared toast Toaster type to ReactNode so the toast can carry that link (type-only change, no runtime effect).
`marketplace/listings` with no listing id matched no route and 404'd. Add a redirect so it lands on the same page as `marketplace/`.
Add a "Preview" chip beside the title on the read-only listing detail page, so it is never mistaken for the real assessment it mirrors.
Specs commit (use_transactional_fixtures is false), so a bare per-second timestamp collides whenever two rspec processes start within the same second, tripping unique constraints. Append a random suffix per process.
Gate assessment-marketplace browsing per person rather than per current-course role. A typed allow-list (user / instance / email-domain / everyone rules) grants access to baseline-capable users (course manager/owner or instance instructor/admin anywhere), with individual access blocks as overrides. - AllowlistRule and AccessBlock models, migrations, and per-type uniqueness - RuleMatchQuery / RulePreviewQuery / AccessListQuery for matching and audit - ability component gates :access_marketplace on the allow-list minus blocks - User baseline predicates and delete-user FK handling for both tables - System::Admin CRUD, access-list, and block/unblock endpoints
System::Admin page to manage the marketplace allow-list: add/remove typed rules (specific user, whole instance, or email domain) with a live preview of who each rule would let in, and an open-to-everyone / restrict toggle.
Add the access audit section to the allow-list page: an audit list of everyone with effective access (filterable by status and granting rule), block/unblock controls per user, and the rule-match counts that flag allow-list rules which currently grant access to nobody.
A version IS its publication datetime, and the snapshot it serves is duplicated into a `preview` container course so a published version can never change under the courses that adopted it. Non-admins cannot edit container content.
Deleting the source assessment now orphans the listing instead of destroying it: the marketplace keeps serving the snapshot, and the authoring copy is rebuilt in the container automatically so a new version can still be published.
Nothing rolls back in this suite, so two unscoped `delete_all`s leaked into every later spec file and failed them under some seeds: `User::Email.delete_all` stripped the address off every earlier file's users (surfacing as `SMTP To address may not be blank` in the mailer specs and a nil `user.email` in the external-assessment import specs), and allow-list rows left behind granted `:access_marketplace` to users the ability spec asserts cannot have it. Scope the email cleanup to the domains that file hardcodes, and clear the allow-list tables in the ability spec the way access_list_query_spec.rb already does.
Browse, listing/question preview and duplication all read the current version's snapshot rather than the live source assessment, and a manager can cut a new version from the assessment page.
The banner dates both content vintages rather than numbering them, and cannot be dismissed or muted: it is a statement of fact about the copy, so it stands until the copy is updated or deleted. It offers the in-place update only while no student has submitted; once one has, it explains why no action is possible.
Inside the preview container every listing's snapshots share one title, so the index chip dates each one and links it to the listing it belongs to.
A snapshot in the container course is an ordinary assessment with every management affordance live, and editing one is silently destructive: it changes what future adopters copy for a version that was never published, and stops the version's publication date describing its content. A soft guard only. Nothing is disabled, because the surface is admin-only and the escape hatch for repairing served content without minting a version is deliberate. The banner names the risk and links straight at the source assessment to edit instead, on that assessment's own host since it may live on another instance.
Every listing across instances, with its version history, adoption list and source provenance, plus the actions only an admin has: unlist, re-list, rebuild the authoring copy, and permanently delete a listing that is off the marketplace. The whole read path runs tenant-free — listings span instances while their snapshots live in the preview container — and links to a source assessment are absolute, since a course id only resolves on its own instance's host.
The listings table, its per-listing page, and the two maintenance buttons. Actions sit in one fixed order so a given action always occupies the same slot, and delete is present on every row — disabled until the listing is unlisted — so its tooltip can state the rule rather than leaving the admin to infer it from a missing icon.
LWS49
force-pushed
the
lws49/feat-marketplace-pr8-singleton-attempt
branch
from
July 30, 2026 04:57
9d6c66a to
49f0ffb
Compare
Adds `PreviewLaunchService` and the `launch_preview` endpoint: a previewer is enrolled as a manager of the singleton container course and handed the attempt url for the listing's served snapshot, on the preview host. No copy step — the snapshot the marketplace already serves is what a duplicate would give an adopter, so a preview cannot drift from the adopted copy. Also adds `Listing.serving_assessment?`, which the sandbox lock uses to tell a handed-out snapshot from the rest of the container, and moves the container's description into locales so the sandbox explainer can be written as HTML.
A rehearsal in the sandbox must not reach the outside world. Guarded at the three writers rather than their call sites, so a fourth cannot reintroduce it: - no graded-submission email (the previewer grades and publishes their own work, so it would land in their own inbox for a fake grade), - no task completion pushed to the previewer's real external LMS, - no `pending_staff_reply` flag, which in a container with no staff only ever surfaces as a nag on the Pending tab and the Comments badge.
Both crumb handles on any assessment page fetch `show`, and so does the page itself. One endpoint serving both means a breadcrumb costs the page's ~50 queries to render two strings — and made the marketplace sandbox's crumb allowance a licence to read the whole authoring surface. `show` now renders `crumb` for a `?crumb=true` request, skipping the two `before_action`s the full payload needs. Split on the request rather than on the viewer, so the payload stays the same for everyone.
A previewer is enrolled as a `manager` of the shared container course — the lowest role that can attempt, grade and publish. On the ordinary abilities that is also a licence to read and edit the whole course, and a subtractive allow-list can never be complete: it has to name every component, every custom verb and every collection action, and each new one arrives switched on. `ApplicationPreviewSandboxConcern` inverts it. On the preview instance a non-administrator reaches only what a controller explicitly claims via `preview_sandbox_accessible?`, mirroring how `publicly_accessible?` marks out the unauthenticated surface. A component added tomorrow is denied without anyone remembering to deny it. Alongside it, `restrict_preview_course_reads` revokes the manager grants that would otherwise reach another previewer's submission, the aggregate gradebook and the roster of everyone who has ever previewed here. The lock is per-VIEWER: a system administrator curates the container from inside it, so the sandbox stays navigable for them.
The sandbox exists to rehearse the one assessment the previewer opened, and the lock denies everything else — so leaving live links around only offers them a 403. Sidebar items and breadcrumbs render as inert text instead. Home is withheld separately: it is the one sidebar entry that does not come from the `sidebar` payload, so the jbuilder's de-link never reached it. Gated on `isPreviewRestricted`, not `isPreview`: a system administrator curates the container from inside it and keeps working links. The same flag also keeps them off the sidebar's member identity, which they only hold there because launching a preview enrolled them.
… page Everyone in the sandbox is enrolled, so `CourseShow` skipped the description — yet it is the only thing on that page, since the container has no announcement, todo or activity of its own. Reached only by the administrators who curate the container: the sandbox lock denies this page to a restricted previewer, who is landed straight on a submission and told the same thing by the banner there.
`unique_assessment_id_and_creator_id` allows one submission per (assessment, previewer), and launching a preview resumes it rather than resetting it — so without this, starting over means waiting for the TTL. `Submission#reset_preview!` clears every answer, not just the current ones: a previewer who finalises and unsubmits accumulates Past Answers, which is exactly the trace a reset is meant to erase. It does not reuse `unsubmit`'s `recreate_current_answers`, which preserves in-progress drafts, copies the old content forward as `last_attempt`, and keeps the old rows as history — all correct for a real course, all wrong here. The endpoint never accepts a submission id from the client: the row is always looked up by (assessment, creator), which makes resetting someone else's attempt structurally impossible before the ability check even runs.
Opens the sandbox in a new tab from a listing's preview page, behind a confirmation that says what will happen: a separate sandbox, possibly a sign-in redirect, and no effect on the viewer's own course. `navigateTo` exists as a seam, not an abstraction: jsdom seals the whole navigation surface, so a component that assigns `location.href` inline has no assertable behaviour and its test passes whether or not the branch still exists.
Says what the sandbox is on the page a previewer is actually landed on, and hosts the Reset submission button there. Mounted by `CourseContainer` because the flag lives on the layout payload, the only data available on every course page — but it narrows itself to submission pages: the promise it makes is about work a previewer produces, and the action it hosts has nothing to act on elsewhere. Gated on `isPreview`, not `isPreviewRestricted`: "nothing here is real" is a fact about the course, equally worth saying to an administrator, who also needs the Reset action. Only the LOCK is per-viewer.
A previewer has no grader colleague to refresh the page for them, so finalising a preview submission left them looking at an unmarked attempt with no indication anything was happening. The finalising request now hands back the auto-grading job it just enqueued — absent outside a preview course, and absent on any request that did not itself finalise — and the banner polls it, refetching the submission when it lands. It reads a 404 during polling as a purged sandbox and says so, rather than reporting a generic failure.
"Submission updated successfully" says nothing about what publishing a grade would actually do. In the sandbox the previewer is rehearsing the grader's side, so the toast names the consequence they came to see: the student would now be able to read this grade and feedback.
A submission url whose record does not exist rendered the page's normal shell with empty state, which reads as a broken page rather than a wrong address. The page's own load now redirects to the not-found page on a 404 — deliberately a separate thunk, since the preview banner refetches through `fetchSubmission` and reads the same 404 as a purged sandbox. Three things make that page fit once you arrive: - the redirect carries the address it came from and the page puts it back, so the viewer sees the url they asked for rather than `/404`, the way the route catch-all already behaves; - `/submissions/:id` with no `edit` used to match a parent route with children but no index, rendering an empty outlet inside the course shell for any id, real or invented. An index route redirects it to `edit`; - a restricted previewer gets no "go back home" link — `/` is the sandbox container, which the lock denies, so the link only led to a 403.
Fabric.js re-parents the raw <canvas> node into its own container div, so a sibling rendered before it would need React to `insertBefore` relative to a node that is no longer a direct child — which throws on every toggle, e.g. a redundant re-initialize resetting `isCanvasLoaded`. Appending a trailing sibling never needs a reference node.
`stopRecord()` rejects with "Recorder has already stopped", and nothing awaits it — so it becomes an unhandled rejection, surfacing as a full-page crash under StrictMode's dev-only double mount/unmount on every unrecorded Voice question.
Weekly, keyed on last activity rather than creation so an in-progress rehearsal is never reaped out from under someone. Never touches the container course, the assessment copies or the previewers' enrolments — those are deliberately reused across preview sessions. The cron sets only how long past the TTL a submission may linger, not how long it is kept: starting over is the banner's Reset submission button, not this.
The design doc these cite is local-only and unversioned, so "design §4.2" and friends point at nothing a reader of this repo can open. Each comment already says the thing it was citing.
LWS49
force-pushed
the
lws49/feat-marketplace-pr8-singleton-attempt
branch
from
July 31, 2026 08:38
a746cf4 to
3c9a87a
Compare
LWS49
force-pushed
the
lws49/feat-marketplace-pr6d-audit
branch
5 times, most recently
from
August 1, 2026 06:27
ffb97b6 to
d75b255
Compare
Base automatically changed from
lws49/feat-marketplace-pr6d-audit
to
lws49/feat-marketplace-pr1-foundation
August 1, 2026 09:35
LWS49
force-pushed
the
lws49/feat-marketplace-pr1-foundation
branch
2 times, most recently
from
August 2, 2026 16:44
bbd43f0 to
6eac0c4
Compare
Base automatically changed from
lws49/feat-marketplace-pr1-foundation
to
master
August 2, 2026 17:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.